{*******************************************************************************
* The contents of this file are used with permission, subject to the Mozilla   *
* Public License Version 1.1 (the "License"); you may not use this file except *
* in compliance with the License. You may obtain a copy of the License at      *
* http://www.mozilla.org/MPL/                                                  *
*                                                                              *
* Software distributed under the License is distributed on an "AS IS" basis,   *
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for *
* the specific language governing rights and limitations under the License.    *
*                                                                              *
* The Original Code is distributed as part of the "mediate" product and is     *
* Copyright (C) @home media limited. All Rights Reserved.                      *
*                                                                              *
* Email: support@athomemedia.co.uk                                             *
* Web:   http://www.athomemedia.co.uk                                          *
*******************************************************************************}

unit MP3Tags;

interface

uses
  Classes, SysUtils, BufferedStream, MP3Info;

type
  TAHMMP3File = class(TObject)
  private
    FFile: TBufferedFileStream;
    FFileName: WideString;
    FInfo: TAHMID3FileInfo;
    FTitle: String;
    FSubtitle: String;
    FArtist: String;
    FAlbum: String;
    FAlbumArtist: String;
    FYear: String;
    FComment: String;
    FGenre: String;
    FComposer: String;
    FEncoder: String;
    FCopyright: String;
    FLanguage: String;
    FLink: String;
    FTrack: Word;
    FDuration: LongWord;
    FBitrate: LongWord;
    FImageURL: WideString;
    FImageFound: Boolean;
    FThumbPath: WideString;
    FCreateThumb: Boolean;
    function GetThumbnailURL: WideString;
  protected
    property Stream: TBufferedFileStream read FFile;
    property ThumbnailURL: WideString read GetThumbnailURL;
  public
    constructor Create;
    destructor Destroy; override;
    procedure Clear;
    function OpenFile(FileName: WideString): Boolean;
    function ReadTags: Boolean;
    procedure Close;
    property ThumbnailPath: WideString read FThumbPath write FThumbPath;
    property CreateThumbnails: Boolean read FCreateThumb write FCreateThumb;
    property Title: String read FTitle;
    property Subtitle: String read FSubtitle;
    property Artist: String read FArtist;
    property Album: String read FAlbum;
    property AlbumArtist: String read FAlbumArtist;
    property Year: String read FYear;
    property Comment: String read FComment;
    property Composer: String read FComposer;
    property Encoder: String read FEncoder;
    property Copyright: String read FCopyright;
    property Language: String read FLanguage;
    property Link: String read FLink;
    property Track: Word read FTrack;
    property Duration: LongWord read FDuration;
    property Bitrate: LongWord read FBitrate;
    property Genre: String read FGenre;
    property ImageURL: WideString read FImageURL;
    property ImageFound: Boolean read FImageFound write FImageFound;
  end;


implementation

uses TagUtils, Unicode;

type
  // Physical structure of ID3v1 tag
  TID3v1Record = record
    Header: array [1..3] of Char;
    Title: array [1..30] of Char;
    Artist: array [1..30] of Char;
    Album: array [1..30] of Char;
    Year: array [1..4] of Char;
    Comment: array [1..30] of Char;
    Genre: Byte;
  end;

  // Physical strcuture of ID3v2 header
  TID3v2Record = record
    Header: array [1..3] of Char;
    Version: Byte;
    Revision: Byte;
    Flags: Byte;
    Size: array [1..4] of Byte;
  end;

  // Physical structure of ID3v2.2 frame header
  TID3v22Frame = record
    ID: array [1..3] of Char;
    Size: array [1..3] of Byte;
  end;

  // Physical structure of ID3v2.3 & 2.4 frame header
  TID3v23Frame = record
    ID: array [1..4] of Char;
    Size: Integer;
    Flags: Word;
  end;

  TID3v2FrameTypes = (ftTitle, ftLeadArtist, ftAlbum, ftTrackNum, ftYear,
                      ftContentType, ftComment, ftComposer, ftEncodedBy,
                      ftCopyright, ftLanguage, ftLink, ftRecordingDate,
                      ftOriginalArtist, ftContentGroup, ftOriginalAlbum,
                      ftPicture, ftSubTitle, ftSongLength, ftMixArtist);

  TID3v2Frames = array[TID3v2FrameTypes] of String;

const
  // Names of supported frames for ID3v2 tags
  ID3v22Frames: TID3v2Frames = ( 'TT2', 'TP1', 'TAL', 'TRK', 'TYE', 'TCO',
                                 'COM', 'TCM', 'TEN', 'TCR', 'TLA', 'WXX',
                                 'TOR', 'TOA', 'TT1', 'TOT', 'PIC', 'TT3',
                                 'TLE', 'TP4' );
  ID3v23Frames: TID3v2Frames = ( 'TIT2', 'TPE1', 'TALB', 'TRCK', 'TYER', 'TCON',
                                 'COMM', 'TCOM', 'TENC', 'TCOP', 'TLAN', 'WXXX',
                                 'TDRC', 'TOPE', 'TIT1', 'TOAL', 'APIC', 'TIT3',
                                 'TLEN', 'TPE4' );

  TAG_ID3v10 = 1; // ID3v1.0
  TAG_ID3v11 = 2; // ID3v1.1
  TAG_ID3v22 = 2; // ID3v2.2.x
  TAG_ID3v23 = 3; // ID3v2.3.x
  TAG_ID3v24 = 4; // ID3v2.4.x

  SIG_ID3v1 = 'TAG';
  SIG_ID3v2 = 'ID3';
  SIG_UNICODE = #1; // Flag byte for unicoded string



// Helper functions

function ReadV1Tags(Owner: TAHMMP3File; var TagData: TID3v1Record): Boolean;
begin
  with Owner do
    try
      // ID3v1 tags at end of file (last 128 bytes)
      if Stream = nil then Abort;
      Stream.Seek(Stream.Size - 128, soFromBeginning);
      if Stream.Read(TagData, 128) < 128 then Abort;
      Result := (TagData.Header = SIG_ID3v1);
    except
      Result := False;
    end;
end;

function ReadV2Header(Owner: TAHMMP3File; var TagData: TID3v2Record): Boolean;
begin
  with Owner do
    try
      // ID3v2 tags at beginning of file
      if Stream = nil then Abort;
      if Stream.Read(TagData, 10) < 10 then Abort;
      Result := (TagData.Header = SIG_ID3v2);
    except
      Result := False;
    end;
end;

procedure ReadPictureFrame(Owner: TAHMMP3File; Length: Integer; var Frames: TID3v2Frames);
var
  StartPos: Int64;
  Encoding, Buffy: Byte;
  WBuffy: Word;
  Target: WideString;
  Save: TFileStreamW;
begin
  with Owner do
    if CreateThumbnails and not ImageFound then
    begin
      // Generate target URL for thumbnail
      Target := ThumbnailURL;

      // If target already exists then no point saving, just skip frame
      if FileExistsW(Target) then
        Stream.Seek(Length, soFromCurrent)
      else
      begin
        // Record current stream position
        StartPos := Stream.Position;

        // Read encoding type for description
        Stream.Read(Encoding, 1);

        // Skip mime type - we don't need to know this
        Stream.Read(Buffy, 1);
        while Buffy <> 0 do Stream.Read(Buffy, 1);

        // Skip picture type - we just store the first image
        Stream.Seek(1, soFromCurrent);

        // Skip description - this may be 16 bit string
        if (Encoding = 1) or (Encoding = 2) then
        begin
          Stream.Read(WBuffy, 2);
          while WBuffy <> 0 do Stream.Read(WBuffy, 2);
        end
        else
        begin
          Stream.Read(Buffy, 1);
          while Buffy <> 0 do Stream.Read(Buffy, 1);
        end;

        // Pointing at the picture data now - calculate its size
        Length := Length - (Stream.Position - StartPos);

        // Copy image to a filestream
        Save := TFileStreamW.Create(Target, fmCreate);
        try
          Save.CopyFrom(Stream, Length);
        finally
          Save.Free;
        end;
      end;

      // Don't look for any more images
      ImageFound := True;
    end
    else
      Stream.Seek(Length, soFromCurrent)
end;

procedure ReadV22Frames(Owner: TAHMMP3File; Length: Integer; var Frames: TID3v2Frames);
var
  Frame: TID3v22Frame;
  FrameSize: Integer;
  FrameRead: Boolean;
  i: TID3v2FrameTypes;
begin
  with Owner do
  begin
    // Clear current frame contents
    for i := ftTitle to ftMixArtist do Frames[i] := '';

    // Read information from frames (ID3v2.2.x)
    Stream.Seek(10, soFromBeginning);
    while (Stream.Position < Length) and (Stream.Position < Owner.Stream.Size) do
    begin
      // Read frame header, validate & calculate size
      Stream.Read(Frame, 6);
      if not (Frame.ID[1] in ['A'..'Z']) then Break;
      FrameSize := Frame.Size[1] shl 16 + Frame.Size[2] shl 8 + Frame.Size[3];
      FrameRead := False;

      // Check for supported frame type
      for i := ftTitle to ftMixArtist do
        if Frame.ID = ID3v22Frames[i] then
        begin
          if i = ftPicture then
            ReadPictureFrame(Owner, FrameSize, Frames)
          else
          begin
            SetLength(Frames[i], FrameSize);
            Stream.Read(Pointer(Frames[i])^, FrameSize);
          end;

          FrameRead := True;
          Break;
        end;

      // Skip over unsupported frames
      if not FrameRead then Stream.Seek(FrameSize, soFromCurrent);
    end;
  end;
end;

procedure ReadV23Frames(Owner: TAHMMP3File; Length: Integer; var Frames: TID3v2Frames);
var
  Frame: TID3v23Frame;
  FrameSize: Integer;
  FrameRead: Boolean;
  i: TID3v2FrameTypes;
begin
  with Owner do
  begin
    // Clear current frame contents
    for i := ftTitle to ftMixArtist do Frames[i] := '';

    // Read information from frames (ID3v2.3.x & ID3v2.4.x)
    Stream.Seek(10, soFromBeginning);
    while (Stream.Position < Length) and (Stream.Position < Stream.Size) do
    begin
      // Read frame header, validate & calculate size
      Stream.Read(Frame, 10);
      if not (Frame.ID[1] in ['A'..'Z']) then Break;
      FrameSize := Swap32(Frame.Size);
      FrameRead := False;

      // Check for supported frame type
      for i := ftTitle to ftMixArtist do
        if Frame.ID = ID3v23Frames[i] then
        begin
          if i = ftPicture then
            ReadPictureFrame(Owner, FrameSize, Frames)
          else
          begin
            SetLength(Frames[i], FrameSize);
            Stream.Read(Pointer(Frames[i])^, FrameSize);
          end;

          FrameRead := True;
          Break;
        end;

      // Skip over unsupported frames
      if not FrameRead then Stream.Seek(FrameSize, soFromCurrent);
    end;
  end;
end;

function GetV2TagSize(const TagData: TID3v2Record): Integer;
begin
  // Get total tag size
  Result := TagData.Size[1] * $200000 +
            TagData.Size[2] * $4000 +
            TagData.Size[3] * $80 +
            TagData.Size[4] + 10;
  if TagData.Flags and $10 = $10 then Inc(Result, 10);
end;

function GetString(const Source: String): String;
var
  i: Integer;
  FirstByte, SecondByte: Byte;
  UnicodeChar: WideChar;
begin
  // Convert string from unicode if needed and trim spaces
  if (Length(Source) > 0) and (Source[1] = SIG_UNICODE) then
  begin
    Result := '';
    for i := 1 to ((Length(Source) - 1) div 2) do
    begin
      FirstByte := Ord(Source[i * 2]);
      SecondByte := Ord(Source[i * 2 + 1]);
      UnicodeChar := WideChar(FirstByte or (SecondByte shl 8));
      if UnicodeChar = #0 then Break;
      if FirstByte < $FF then Result := Result + UnicodeChar;
    end;
    Result := Trim(Result);
  end
  else
    Result := Trim(Source);
end;

function GetContent(const Preferred, Alternative: String): String;
begin
  // Get content preferring the first content
  Result := GetString(Preferred);
  if Result = '' then Result := GetString(Alternative);
end;

function ExtractTrack(const Track: String): Word;
var
  Work: String;
  i: Integer;
begin
  // Extract track from string
  Work := GetString(Track);
  i := Pos('/', Work);
  if i > 0 then Result := StrToIntDef(Copy(Work, 1, i - 1), 0)
           else Result := StrToIntDef(Track, 0);
end;

function ExtractYear(const Year, Date: String): String;
begin
  // Extract year from strings
  Result := GetString(Year);
  if Result = '' then Result := Copy(GetString(Date), 1, 4);
end;

function ExtractGenre(const Genre: String): String;
begin
  // Extract genre from string
  Result := GetString(Genre);
  if Pos(')', Result) > 0 then Delete(Result, 1, LastDelimiter(')', Result));
end;

function ExtractText(Source: String; LanguageID: Boolean): string;
var
  Separator: string;
  EncodingID: Char;
begin
  // Extract null terminated text string
  Result := '';
  if Length(Source) > 0 then
  begin
    EncodingID := Source[1];
    if EncodingID = SIG_UNICODE then Separator := #0#0
                                else Separator := #0;
    if LanguageID then Delete(Source, 1, 4)
                  else Delete(Source, 1, 1);
    Delete(Source, 1, Pos(Separator, Source) + Length(Separator) - 1);
    Result := GetString(EncodingID + Source);
  end;
end;


// TAHMMP3File

constructor TAHMMP3File.Create;
begin
  inherited;

  FInfo := TAHMID3FileInfo.Create;

  Clear;
end;

destructor TAHMMP3File.Destroy;
begin
  FFile.Free;
  FInfo.Free;

  inherited;
end;

function TAHMMP3File.GetThumbnailURL: WideString;
begin
  FImageURL := FThumbPath + GetThumbnailName(FFileName);
  Result := FImageURL;
end;

procedure TAHMMP3File.Clear;
begin
  FTitle := '';
  FSubtitle := '';
  FArtist := '';
  FAlbum := '';
  FAlbumArtist := '';
  FYear := '';
  FComment := '';
  FGenre := '';
  FComposer := '';
  FEncoder := '';
  FCopyright := '';
  FLanguage := '';
  FLink := '';
  FTrack := 0;
  FDuration := 0;
  FBitrate := 0;
  FImageURL := '';
  FImageFound := False;
end;

function TAHMMP3File.OpenFile(FileName: WideString): Boolean;
begin
  try
    FFile := TBufferedFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    Result := True;
  except
    Result := False;
  end;
end;

function TAHMMP3File.ReadTags: Boolean;
var
  v1TagData: TID3v1Record;
  v2TagData: TID3v2Record;
  Frames: TID3v2Frames;
  TagSize: Integer;
begin
  // Set default result - success
  Result := True;
  Clear;

  try
    // Attempt to read v2 tags first then failover to v1
    if ReadV2Header(Self, v2TagData) then
    begin
      // Calculate total tag size
      TagSize := GetV2TagSize(v2TagData);
      if TagSize > FFile.Size then TagSize := 0;

      // Read information from frames for supported versions
      case v2TagData.Version of
        TAG_ID3v22: ReadV22Frames(Self, TagSize, Frames);
        TAG_ID3v23: ReadV23Frames(Self, TagSize, Frames);
        TAG_ID3v24: ReadV23Frames(Self, TagSize, Frames);
        else Exit;
      end;

      // Parse MPEG data after the v2 header
      FInfo.Read(FFile, FFile.Position);

      // Populate properties from v2 frames
      FTitle := GetContent(Frames[ftTitle], Frames[ftContentGroup]);
      FArtist := GetContent(Frames[ftLeadArtist], Frames[ftOriginalArtist]);
      FAlbum := GetContent(Frames[ftAlbum], Frames[ftOriginalAlbum]);
      FAlbumArtist := GetContent(Frames[ftMixArtist], FArtist);
      FTrack := ExtractTrack(Frames[ftTrackNum]);
      FYear := ExtractYear(Frames[ftYear], Frames[ftRecordingDate]);
      FGenre := ExtractGenre(Frames[ftContentType]);
      FComment := ExtractText(Frames[ftComment], True);
      FComposer := GetString(Frames[ftComposer]);
      FEncoder := GetString(Frames[ftEncodedBy]);
      FCopyright := GetString(Frames[ftCopyright]);
      FLanguage := GetString(Frames[ftLanguage]);
      FLink := ExtractText(Frames[ftLink], False);
      FDuration := StrToIntDef(GetString(Frames[ftSongLength]), 0);
      if FDuration > 10000 then FDuration := 0; // sense check for stupid values
      if (FDuration = 0) and (FInfo.LengthInSec > 0) then FDuration := FInfo.LengthInSec;
      if FInfo.Bitrate > 0 then FBitrate := FInfo.Bitrate * 1000;
    end
    else if ReadV1Tags(Self, v1TagData) then
    begin
      // Parse MPEG data before the v1 header
      FInfo.Read(FFile, 0);

      // Populate properties from v1 tags
      FTitle := TrimRight(v1TagData.Title);
      FArtist := TrimRight(v1TagData.Artist);
      FAlbum := TrimRight(v1TagData.Album);
      FAlbumArtist := FArtist;
      FYear := TrimRight(v1TagData.Year);
      FGenre := GetV1Genre(v1TagData.Genre);
      if FInfo.LengthInSec > 0 then FDuration := FInfo.LengthInSec;
      if FInfo.Bitrate > 0 then FBitrate := FInfo.Bitrate * 1000;
      // Check for track no. in comments (ID3v1.1.x)
      if ((v1TagData.Comment[29] = #0) and (v1TagData.Comment[30] <> #0)) or
         ((v1TagData.Comment[29] = #32) and (v1TagData.Comment[30] <> #32)) then
      begin
        FComment := TrimRight(Copy(v1TagData.Comment, 1, 28));
        FTrack := Ord(v1TagData.Comment[30]);
      end
      else
        FComment := TrimRight(v1TagData.Comment);
    end
    else
    begin
      // Unknown tag format
      Result := False;
    end;
  except
    // Damaged file structure - parsing failed
    Result := False;
  end;
end;

procedure TAHMMP3File.Close;
begin
  FreeAndNil(FFile);
end;


end.
